上一篇,我們學會了如何使用 goroutine
來實現併發,並用 WaitGroup
協調它們。
但是呢,單純的併發執行會帶來一個嚴重的問題:多個 goroutine
同時讀寫同一個變數,會導致數據不一致。
今天會探討怎麼保護共享記憶體,並介紹 Go 語言的併發工具:Mutex
和 RWMutex
。
當多個 goroutine
同時讀寫同一個記憶體變數時,就會發生競爭條件。sync.Mutex
(互斥鎖) 是解決這個問題的工具。
它像一扇只有一把鑰匙的門。
任何 goroutine
想要進入被保護的程式碼區塊(臨界區),都必須先獲得鎖 (Lock
)。
執行完畢後,必須釋放鎖 (Unlock
),下一個等待的 goroutine
才能進入。
// 位於 Day3/mutex/main.go
var (
counter int
mu sync.Mutex // 保護 counter 的鎖
)
func safeIncrement(wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 1000; i++ {
mu.Lock() // 取得鎖
counter++ // --- 臨界區開始 ---
mu.Unlock() // --- 臨界區結束 --- 釋放鎖
}
}
這確保了 counter++
操作的原子性,最後的準確結果。
Mutex 雖然能保證資料安全,但它是有成本的。
它透過強制序列化來解決衝突——一次只允許一個 goroutine 進入臨界區。
在高競爭的場景下,這會導致大量的 goroutine 阻塞和等待,從而嚴重影響程式的並行效能。
它犧牲了並行性來換取安全性。
把它看作是一種必要的惡,一種在沒有更好選擇時才使用的「蠻力」手段。
到目前為止,我們已經有了 goroutine
、WaitGroup
和 Mutex
三個獨立的工具。
現在將它們組合起來而且解決一個實際問題:如何構建一個允許多個 goroutine
同時操作,但結果依然準確的計數器?
這裡也會用到封裝 (Encapsulation) 的思想。
把你「需要保護的資料 value
」 和「用來保護它的鎖mu
」 包在同一個結構體 (struct
) 裡面。
這樣任何外部程式碼都不能直接觸碰 value
。你必須透過我們定義好的方法 (Increment
, Value
) 來存取它,而這些方法內部已經幫你處理好了加鎖和解鎖的邏輯。
這極大地降低了出錯的風險。你不可能會忘記加鎖,因為你根本沒有機會直接操作資料。
goroutine
, WaitGroup
, Mutex
的協同工作// 位於 Day3/example/main.go
package main
import (
"fmt"
"sync"
)
// SafeCounter 是一個併發安全的計數器
// 它「封裝」了計數值和一個互斥鎖
type SafeCounter struct {
mu sync.Mutex
value int
}
// Increment 會安全地對計數器加一
func (c *SafeCounter) Increment() {
// Mutex: 在修改 value 前,鎖定計數器,防止其他 goroutine 介入
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
// Value 會安全地讀取計數器的值
func (c *SafeCounter) Value() int {
// Mutex: 即使是讀取,也要鎖定,以防止讀到一個正在被修改的「髒」資料
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
// worker 代表一個獨立的併發任務
func worker(id int, counter *SafeCounter, wg *sync.WaitGroup) {
// WaitGroup: 確保在 worker 函式結束時,通知 WaitGroup 任務已完成
defer wg.Done()
for i := 0; i < 100; i++ {
counter.Increment()
}
fmt.Printf("Worker %d 完成了 100 次計數\n", id)
}
func main() {
fmt.Println("=== 三元組組合技展示 ===")
counter := &SafeCounter{}
var wg sync.WaitGroup
// 我們要啟動 10 個併發任務
numWorkers := 10
for i := 1; i <= numWorkers; i++ {
// WaitGroup: 每啟動一個 worker,計數器就加 1
wg.Add(1)
// goroutine: 使用 'go' 關鍵字,讓每個 worker 在獨立的 goroutine 中併發執行
go worker(i, counter, &wg)
}
// WaitGroup: 阻塞主程式,直到所有 worker 都呼叫了 Done(),計數器歸零
wg.Wait()
fmt.Printf("最終計數結果:%d\n", counter.Value())
fmt.Printf("預期結果:1000(10個worker × 100次)\n")
}
執行結果:
goroutine, WaitGroup, Mutex 的協同工作
sync.RWMutex
雖然 Mutex
很好用,但它有一個限制:即使是讀取操作,也必須等待寫入鎖釋放。
在讀取頻繁但寫入較少的場景下,Mutex
的效能瓶頸會非常明顯。
這時,我們可以使用更精細的工具:sync.RWMutex
(讀寫鎖)。
RLock()
: 取得讀取鎖,允許多個 goroutine
同時讀取。
RUnlock()
: 釋放讀取鎖。
Lock()
: 取得寫入鎖,會阻塞所有讀取和寫入操作。
Unlock()
: 釋放寫入鎖。
使用 RWMutex
重構 SafeCounter
的 Value
方法:
// SafeCounterRWMutex 是一個使用讀寫鎖的併發安全計數器
type SafeCounterRWMutex struct {
mu sync.RWMutex
value int
}
// Increment 依然使用寫入鎖,確保修改時沒有其他操作
func (c *SafeCounterRWMutex) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
// Value 使用讀取鎖,允許多個讀取同時發生
func (c *SafeCounterRWMutex) Value() int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.value
}
這個小小的改變,卻能大幅提升程式在讀多寫少場景下的並行效能。
如何保護共享記憶體:
Mutex
是保護共享記憶體的「蠻力」工具,它透過犧牲並行性來保證資料一致性。
RWMutex
是 Mutex
的進階版,能優化讀多寫少場景的效能。
封裝:將資料和其對應的鎖封裝在一個結構體內,是推薦的實踐方式。
Mutex
是解決競爭條件的基礎手段,但透過鎖來保護共享資源的方式有效能上的代價。
這讓我們去尋找更優雅的方案,而 Go 語言更推崇另一種併發模型——Channel。下篇將繼續探討。
Mutex in Golang With Examples - GeeksforGeeks:解釋 sync.Mutex 如何防止競態條件
Go by Example: Mutexes:簡單明瞭的程式碼範例可以快速掌握 Mutex
的基本用法。
Go Concurrency Patterns:這是 Go 語言創始人 Rob Pike 的經典演講,解釋了 Go 語言的併發哲學,對理解為什麼 Go 更推崇 Channel 而非傳統鎖有很大幫助。